Skip to content

✨ feat(fmt): a string-formatting engine unifying repr, str, format, and the IPython reprs - #855

Draft
nstarman wants to merge 35 commits into
GalacticDynamics:mainfrom
nstarman:claude/string-formatting-engine-7776fd
Draft

✨ feat(fmt): a string-formatting engine unifying repr, str, format, and the IPython reprs#855
nstarman wants to merge 35 commits into
GalacticDynamics:mainfrom
nstarman:claude/string-formatting-engine-7776fd

Conversation

@nstarman

@nstarman nstarman commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #683.

Makes repr, str and __format__ one rendering reached three ways, behind a domain-agnostic engine that coordinax and galax can register into as peers.

Formatting a Quantity

Every result below is real output, from q = u.Q([1.23456, 22.5, 333.75], "m") and s = u.Q(3.14159, "m").

Layout — how the pieces are arranged:

spec result
f"{q}" Quantity([ 1.23456, 22.5 , 333.75 ], unit='m')
f"{q:mul}" [ 1.23456, 22.5 , 333.75 ] * m
f"{q:bare}" [ 1.23456, 22.5 , 333.75 ] m
f"{q:call}" Quantity([ 1.23456, 22.5 , 333.75 ], unit='m')
f"{q:compact}" Q([ 1.23456, 22.5 , 333.75 ], unit='m')
f"{q:full}" Quantity(Array([ 1.23456, 22.5 , 333.75 ], dtype=float32), unit='m')

Value — how much of the payload shows, including a per-element format spec:

spec result
f"{q:type}" f32[3] m
f"{q:array}" Array([ 1.23456, 22.5 , 333.75 ], dtype=float32) m
f"{q:.2f}" [1.23, 22.50, 333.75] m
f"{q:mul-.2f}" [1.23, 22.50, 333.75] * m
f"{q:->10.2f}" [------1.23, -----22.50, ----333.75] m
f"{q:=>9.1f}" [======1.2, =====22.5, ====333.8] m

Those last two are the parser earning its keep: ->10.2f uses - as a fill character and =>9.1f uses = — the two characters the grammar itself uses, as keyword separator and axis assignment. Neither is mistaken for one, because the format spec is terminal.

Unit — which spelling:

spec result
f"{q:name}" [ 1.23456, 22.5 , 333.75 ] meter
f"{q:dim}" [ 1.23456, 22.5 , 333.75 ] length

Markup:

spec result
f"{q:html}" <span>[ 1.23456, 22.5 , 333.75 ]</span> <span>m</span>
f"{q:html-mul}" <span>[ 1.23456, 22.5 , 333.75 ]</span> * <span>m</span>
f"{q:html-type-bare}" <span>f32[3]</span> <span>m</span>
f"{q:latex}" $[ 1.23456,~ 22.5 ,~333.75 ] \, \mathrm{m}$
f"{q:latex-mul-name-.2f}" $[1.23,~22.50,~333.75] \; meter$

LaTeX gets its own spelling of the separator, because math mode discards literal whitespace: bare is \, (the thin space set between a quantity and its unit) and mul is the wider \;. A plain space would render as nothing at all.

Composed, and written explicitly — axes are order-independent, and any keyword may be spelled as the assignment it is, axis=word:

spec result
f"{q:mul-name-.2f}" [1.23, 22.50, 333.75] * meter
f"{q:unit=name}" [ 1.23456, 22.5 , 333.75 ] meter
f"{q:markup=latex-sep=mul}" $[ 1.23456,~ 22.5 ,~333.75 ] \; \mathrm{m}$

Scalars behave the same, and a bare format spec stays astropy-shaped:

spec result
f"{s:.3g}" 3.14 m
f"{s:mul-.3g}" 3.14 * m
f"{s:type}" weak_f32[] m

The grammar

A spec is a --joined run of keywords, optionally ending in a Python format spec applied per element:

spec := keyword ("-" keyword)* ["-" <python format spec>]

The parse is total and strictly left-to-right: consume keywords, and the first token that is not one ends keyword parsing — everything from there, including any further -, is the format spec. That single rule is what keeps the grammar unambiguous once arbitrary format specs are in play: a spec may contain - itself, as a sign flag (-.2f) or a fill character (->10.2f), and neither can be mistaken for a component boundary. Its one consequence is that the format spec goes last.

Six axes, each keyword setting exactly one:

axis keywords default layouts
layout call, product product both
value array, values, type, or a format spec values both (text: product)
markup text, html, latex text product
unit symbol, name, dim symbol both
separator mul, bare bare product
abbreviation abbrev off call

Aliases expand textually into keywords, so one can never mean something the grammar cannot: compact=call-abbrev, full=call-array, dims=call-dim.

Keywords are order-independent (html-bare == bare-html), which requires a flat namespace — so any keyword may also be written as the assignment it is, axis=word, when two packages want the same word. = says what a keyword does -- sets an axis to a value -- so bare dim is shorthand for unit=dim. Bare resolves while exactly one axis claims it, which is every word today; when two claim one, the bare form is ambiguous and the error names both alternatives. It is per-token: a collision costs one assignment on one word, not a rewritten spec.

Architecture

Split along the seam it will be cut at:

  • unxt._src.fmt.engine — domain-agnostic. Fragments, markup, the wadler-lindig feed, the pparts dispatcher, the parser, Spec, layouts, the axis registry. Imports nothing from unxt, jax, numpy or astropy; a test asserts that from its import list, so the claim can't rot into prose. Destined to become a standalone package named pparts.
  • unxt._src.fmt.axes — unxt's layer: the axes it registers, the aliases, the array helpers.
  • unxt._pparts — spelled so extraction is dropping one underscore: from unxt._pparts import pparts, PPart becomes from pparts import ... with no other edit. Re-exports 9 names, the intended package surface.

Axes and aliases are registered, not hardcoded, so a downstream axis is indistinguishable from a built-in one — order-independent, defaulted when unnamed, layout-scoped. A test registers a fake downstream axis and checks all of that.

call layout renders through the object's own __pdoc__. That's load-bearing: __pdoc__ is where a type says how to reconstruct itself, which is what keeps eval(repr(usys)) == usys true for all nine unit-system realizations.

Breaking changes

Unit systems (💥 boom commit):

before after
repr unitsystem(kpc, ...) or LengthUnitSystem(length=Unit("km")) unitsystem(['kpc', 'Myr', 'solMass', 'rad'])
str LTMAUnitSystem(length, time, mass, angle) unitsystem(kpc, Myr, solMass, rad)
dimension names str f"{usys:dims}"

repr now reconstructs the object. Two details are load-bearing, both found by testing the property rather than assuming it — a single-unit system needs the list form (a lone string is a system name), and the measured-constant realizations need a full-precision scale fallback because to_string() truncates past six significant figures.

Quantities:

  • A value spec now formats each element of an array instead of raising TypeError. f"{q:.2f}" on [1.234, 2.345]'[1.23, 2.35] m'.
  • The product-style default separator is bare, so f"{q:.2f}" stays astropy-shaped (3.14 m, not 3.14 * m).
  • _repr_html_ renders <span>m</span> rather than <span>Unit("m")</span>, and these reprs render a jit-traced value as a shape/dtype summary instead of raising TracerArrayConversionError.

Downstream: galax has ~34 doctest lines that change (nested LTMAUnitSystem(length=Unit("kpc"), ...) inside potential reprs). It pins unxt>=1.10.3 with no upper bound, so its CI goes red on release regardless of merge order — that needs either a unxt<X cap before release or a short red window. coordinax and potamides are unaffected.

Notable design points

One value-rendering path. A value spec used to have two implementations — a scalar-only astropy-compatible fallback and the engine — so the same spec meant different things depending on whether an unrelated keyword happened to be present. Now .2f and mul-.2f differ only in the separator they name.

The format spec is a value of the value axis, not a key beside it. That makes type-.2f an ordinary "value is set twice" error rather than a hand-written consistency check, and it removed the last place the engine hardcoded unxt's vocabulary ("value", "values", "product" as string literals — invisible to the import-list seam test).

Inert axes are detectable, opt-in. An axis is offered to every type and quietly ignored by one with no concept of it — that's what lets a composite forward axes it's never heard of, so f"{interval:name}" reaches the nested quantities. It also means a spec can be accepted and do nothing (f"{q:name}" on km / s, f"{q:dims}" on a Quantity). Applicability can't be declared per type — the answer depends on the object tree, unknown before rendering — so WARN_INERT_AXES asks by experiment: re-render with the axis defaulted and see if the output moves. Off by default (each probe is a second render; 1.7×–5×), and it lives on the engine so each consuming library sets it for itself.

Bugs found here, fixed separately

Two were split out and merged ahead of this, and this branch is rebased on both:

  1. __pdoc__ discarded a caller's custom= hook🐛 fix(quantity): stop __pdoc__ discarding a caller's custom= hook #869.
  2. _repr_latex_ corrupted QuantityMatrix🐛 fix(quantity): stop _repr_latex_ corrupting units without _repr_latex_ #870. This PR's own pparts had the same [1:-1] assumption, so it carries the hardening across via a shared unwrap_math.

Follow-up

siunitx output (\qty{1.5}{m}) is designed and scoped in #907, to be built as a
third value of the markup axis. Plain latex is deliberately left alone there —
siunitx is something a user asks for, not a change to math-mode markup.

Type-scoped overrides (f"{d:mul-type=Q[.2f]}", so a container can format the
quantities in it differently from the raw arrays) are designed and scoped in
#908. That issue also records the one live bug this grammar has: a child with no
pparts makes a value spec fatal for the whole container, which is fixable on
its own and ahead of the feature.

Notes for review

32 commits, each green and independently revertible; the 💥 boom commit is the point of no return.

Two design decisions argued in the commit messages: the engine uses a module-local plum.Dispatcher (the global one keys on bare __name__, so two libraries defining pparts would silently merge method tables), and composites nest child parts rather than splicing them flat (a wadler-lindig group is all-or-nothing, so splicing makes every break point break together).

Docs: the full grammar, axis table, extension guide and layering live in a new docs/guides/formatting.md; docs/conventions.md keeps only the conventions and links out.

Milestone: v2.1.0, but this carries breaking changes — if you want strict semver that wants a major milestone instead.

Copilot AI lite review requested due to automatic review settings August 8, 2026 00:15
@github-actions github-actions Bot added 📝 Add / update documentation Add or update documentation. ✅ Add / update / pass tests Add, update, or pass tests. 🧩 unxts-interop-gala Issues/PRs affecting the unxts.interop.gala namespace package 🧩 unxts-linalg Issues/PRs affecting the unxts.linalg namespace package ♻️ Refactor code Refactor code. ✨ Introduce new features Introduce new features. 🐛 Fix a bug Fix a bug. 💥 Introduce breaking changes Introduce breaking changes. labels Aug 8, 2026
@nstarman
nstarman requested a review from adrn August 8, 2026 00:17
@nstarman

nstarman commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@adrn the goal isn't to make a package called unxts.fmt but to develop this as a private implementation "in-house", then spin it off as a small utility package that unxt, coordinax, and galax can all use.

@nstarman nstarman added this to the v2.1.0 milestone Aug 8, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new unxt.fmt formatting engine that unifies how repr, str, format, and IPython rich representations are produced, enabling new types to participate by registering a single pparts implementation. It also updates unit system rendering to ensure repr round-trips via eval(repr(usys)) == usys, and aligns documentation/tests with the new output.

Changes:

  • Added unxt.fmt (public) and unxt._src.fmt (implementation) providing a parts-based formatting pipeline for text/HTML/LaTeX plus FORMAT_PRESETS for f-string specs (e.g. :compact, :mul, :latex).
  • Reworked unit system rendering via a shared AbstractUnitSystem.__pdoc__, updated dataclass repr=False for all unit-system shapes, and added tests for repr round-tripping and readable str.
  • Routed Quantity IPython markup reprs (_repr_html_, _repr_latex_) through the new engine and registered UnitsMatrix parts to preserve structure and prevent LaTeX slicing regressions.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/unit/test_unitsystems.py Adds property-style tests ensuring unit-system repr round-trips and str/dims formatting behaves as intended.
tests/unit/test_quantity_printing.py Adds tests covering format presets and the __pdoc__ custom-hook chaining behavior.
tests/unit/test_fmt.py New focused test suite for unxt.fmt presets, markup escaping, layout behavior, extensibility, and JIT interactions.
src/unxt/fmt.py New public module re-exporting the formatting engine API.
src/unxt/_src/unitsystems/flags.py Updates doctest outputs for unit-system rendering changes.
src/unxt/_src/unitsystems/core.py Updates doctests and ensures dynamic unit-system dataclasses don’t shadow base rendering (repr=False).
src/unxt/_src/unitsystems/builtin.py Switches built-in unit-system dataclasses to repr=False; adds DimensionlessUnitSystem.__pdoc__.
src/unxt/_src/unitsystems/base.py Adds round-tripping unit-string logic and centralizes unit-system rendering in __pdoc__; updates __repr__, __str__, __format__.
src/unxt/_src/quantity/mixins.py Routes IPython HTML/LaTeX repr methods through the new formatting engine.
src/unxt/_src/quantity/base.py Fixes __pdoc__ custom hook clobbering by chaining hooks; routes __format__ through pspec; adds _chain_custom.
src/unxt/_src/fmt.py New core formatting engine: parts tree model, doc/markup consumers, preset table, and fallback behavior.
src/unxt/init.py Exposes fmt at the package top level.
README.md Updates unit-system rendering examples to match new repr.
packages/unxts.linalg/tests/test_printing.py Adds regression tests ensuring QuantityMatrix markup output is correct (esp. LaTeX).
packages/unxts.linalg/src/unxts/linalg/_src/_units_matrix.py Registers UnitsMatrix with unxt.fmt.pparts to render structured unit tuples correctly.
packages/unxts.interop.gala/docs/index.md Updates doctest output for unit-system rendering.
packages/unxts.interop.gala/docs/guide.md Updates doctest output for unit-system rendering.
packages/unxts.interop.gala/docs/api.md Updates doctest output for unit-system rendering.
docs/interop/dataclassish.md Updates example output to reflect new str rendering of unit systems.
docs/index.md Updates doctest outputs for unit systems to match new repr.
docs/guides/units_and_systems.md Updates doctest outputs for unit systems to match new repr.
docs/api/index.md Adds fmt to the API docs toctree.
docs/api/fmt.md New API docs page for unxt.fmt.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/unxt/_src/fmt.py Outdated
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.83%. Comparing base (cd54e7a) to head (4cb512a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #855      +/-   ##
==========================================
+ Coverage   99.80%   99.83%   +0.03%     
==========================================
  Files          84       88       +4     
  Lines        4017     4340     +323     
  Branches      313      366      +53     
==========================================
+ Hits         4009     4333     +324     
  Misses          4        4              
+ Partials        4        3       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/unxt/_src/unitsystems/base.py Outdated
nstarman added a commit to GalacticDynamics/galax that referenced this pull request Aug 8, 2026
galax declares `unxt>=1.11.2` with no upper bound, but has never been resolved
or tested against unxt 2.x -- the lock has only ever held a 1.x. Today nothing
declares that boundary: `uv` considers unxt 2.0.0 during resolution (it is
compatible on `requires-python`) and backtracks to 1.11.x for transitive
reasons. That is incidental, not a guarantee, and it disappears the moment the
surrounding constraints shift.

Make the tested boundary explicit. The resolved version is unchanged --
1.11.2, as before -- so this is metadata only; `uv.lock` records the new
specifier and nothing else.

There is a concrete change coming that this guards against. unxt is making unit
system `repr`/`str` round-trippable
(GalacticDynamics/unxt#855), which rewrites the
`LTMAUnitSystem( length=Unit("kpc"), ...)` form that 32 doctest lines here pin,
18 of them in `_interop/galax_interop_gala/potential.py` and 12 in
`..._galpy/potential.py`. Those updates belong with the port to unxt 2.x, not
ahead of it.

Lift this cap in the PR that does that port.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@nstarman nstarman added 🧩 unxt Issues/PRs affecting the main unxt package 🧩 unxt-api Issues/PRs affecting the unxt-api workspace package 🧩 unxt-hypothesis Issues/PRs affecting the unxt-hypothesis workspace package 🧩 unxts-api Issues/PRs affecting the unxts.api namespace package 🧩 unxts-hypothesis Issues/PRs affecting the unxts.hypothesis namespace package 🧩 unxts-interop-matplotlib Issues/PRs affecting the unxts.interop.matplotlib namespace package 🧩 unxts-interop-xarray Issues/PRs affecting the unxts.interop.xarray namespace package 🧩 unxts-parametric Issues/PRs affecting the unxts.parametric namespace package labels Aug 8, 2026
@nstarman nstarman closed this Aug 8, 2026
@nstarman nstarman reopened this Aug 8, 2026
@nstarman
nstarman force-pushed the claude/string-formatting-engine-7776fd branch from ab86974 to c77438d Compare August 8, 2026 16:07
@nstarman
nstarman marked this pull request as draft August 8, 2026 20:33
nstarman and others added 8 commits August 20, 2026 15:52
FORMAT_PRESETS is a single dict shared by every type that routes __format__
through unxt._fmt.pspec, so a preset name means the same thing across
`unxt` -- that convention was implicit in the code and undocumented outside
its own docstrings.

Write down the two axes a preset varies along (call-style vs product-style
structure, text/html/latex markup), the current 8-preset table, and the rules
for adding a preset or joining the engine as a new type. Calls out the one
easy-to-miss naming gotcha (`short` vs `compact`, which cut different axes
despite sounding like synonyms) and the one type-specific precedent (`dims`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every product-style combination needed its own FORMAT_PRESETS entry, so
html/latex only ever existed with the multiplication separator baked in --
there was no way to ask for html-bare or latex-short without adding a new
dict row per combination, and three independent axes (markup, array
verbosity, separator) would need one entry each for every combination.

Parse a spec as a set of up to three optional tokens instead: markup (html,
latex; default text), array (short; default the compact form), separator
(mul, bare; default mul). Order doesn't matter -- "html-bare" and "bare-html"
are the same request -- but "<markup>-<array>-<separator>" is the canonical
spelling used in docs and error messages. The existing single-word presets
(mul, bare, short, html, latex) are now the case where every component but
one is omitted, not a separate alias table, which is what makes "html" mean
"html-mul" for free rather than needing its own entry.

FORMAT_PRESETS keeps only the call-style entries (full, compact, dims) that
don't fit this grammar. bad_spec's error message and the pspec/pspec_fallback
docstrings describe both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e into the DSL

The array component was share/compact only, and the unit always rendered its
short/symbol name -- a spec like ".3g" only ever worked scalar-only, through
the unrelated pspec_fallback path, with no way to combine it with markup, a
separator, or a summary array. Extend both axes so they compose with the rest
of the grammar:

- The array component may carry a trailing Python format spec, applied per
  element via numpy.array2string's formatter (e.g. "mul-.3g", "compact-.3g",
  or bare ".3g" combined with any other token). "compact" is now a nameable
  array token (previously implicit-only) so it has something to attach a
  value spec to on its own. "short" (a shape/dtype summary) has no per-element
  values to format, so combining it with a value spec is rejected.
- A new unit component, "long", renders astropy's long_names in place of the
  short/symbol form, falling back to the short form (not raising) for a
  composite or dimensionless unit with no long name.

A value spec still needs a real DSL token to activate this path -- a bare
".3g" or ":>10" (":" as a fill character) has no keyword in it at all, so it
is untouched and keeps going through the unchanged, scalar-only
pspec_fallback, exactly as before.

Fixes a collision this surfaced: "compact" is both a pre-existing call-style
FORMAT_PRESETS key and now a valid array-component token. pspec checks
FORMAT_PRESETS before parsing the DSL so a bare f"{q:compact}" keeps its
original meaning; only "compact" combined with something else (e.g.
"compact-.3g") reaches the DSL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…"values"

"compact" was both a pre-existing call-style FORMAT_PRESETS key and the array
component's nameable default-form token -- and never actually load-bearing as
the latter: a bare "compact" is always intercepted by FORMAT_PRESETS (checked
first), and "compact" combined with anything else is indistinguishable from
using any other real token (e.g. "mul", already a pure no-op default itself)
as the anchor. So "compact-.3g" and "mul-.3g" always produced the identical
parsed spec -- the word bought no expressiveness, only a naming collision.

Rename it to "values", which cannot collide with any FORMAT_PRESETS entry and
still reads clearly ("show the values, at this precision"). The ordering fix
(FORMAT_PRESETS checked before the DSL parse) stays regardless, as general
defensive design for the next token that gets added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The format DSL had two disjoint systems -- a FORMAT_PRESETS table for
call-style renderings and a keyword DSL for product-style ones -- and three
root causes between them:

- Two systems that could not compose. `html-compact` leaked past the DSL into
  float.__format__ and raised "Invalid format specifier 'compact' for object
  of type 'float'".
- The value spec was parsed *by elimination* ("whatever is left after the
  keywords"), so `mul-.2f-.3g` silently rejoined into one garbage spec.
- The value spec had two implementations. `.2f` went through pspec_fallback
  (scalar-only, space-joined); `mul-.2f` went through the engine (array-
  capable, '*'-joined) -- so the same spec meant different things depending on
  whether an unrelated keyword happened to be present.

Replace all of it with one grammar. A spec is a '-'-joined run of keywords,
then an optional Python format spec applied per element. The parse is total
and left-to-right: consume keywords, and the first non-keyword token ends
keyword parsing -- everything from there, including any further '-', is the
value spec. That single rule removes the elimination pass and makes a format
spec's own '-' (sign flag "-.2f", fill char "->10.2f") unambiguous.

Six axes, one flat pairwise-disjoint keyword namespace (enforced by a test,
which is what makes order-independence sound rather than hopeful): layout
(call|product), value (array|values|type), markup (text|html|latex), unit
(symbol|name|dim), separator (mul|bare), abbreviation (abbrev). Naming an axis
the chosen layout lacks is a typed error, never a silent no-op. Aliases
(compact, full, dims) expand textually into core keywords, so they can never
mean anything the grammar cannot say.

repr and str are now the same renderer reached with a different Spec, with
unxt.config supplying the components -- the empty spec stops being a special
case. The public config traits keep their spelling; VALUE_FROM_SHORT_ARRAYS is
the one place the two vocabularies meet. call layout still routes through
__pdoc__, which is load-bearing: that is where reconstruction lives, so
eval(repr(usys)) == usys still holds.

BREAKING CHANGE: the product-style default separator is now `bare`, so
`f"{q:.2f}"` is '3.14 m' (astropy-shaped) and `f"{q:html}"` no longer shows a
'*'. `short` is renamed `type` and `long` is renamed `name`. FORMAT_PRESETS
and pspec_fallback are gone. A value spec now formats each element of an
array instead of raising TypeError.

Also fixes value_str collapsing the full-array form onto the type summary:
it pinned short_arrays=True and always passed the summary-building custom
hook, so `array` and `type` rendered identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ponytail-review pass on the new grammar. Three duplications, all of them free
to drift out of sync:

- `parse_spec` spelled out every axis default a second time as
  `seen.get(axis, default)`, alongside the `Spec` field defaults. Axis names
  are exactly `Spec._fields`, so the scanned axes go straight in as
  `Spec(**seen, value_spec=...)` and unset ones fall to the field default.
  Validation moves after construction and reads the built spec. A test pins
  the axis-name/field-name correspondence the call now relies on.
- `VALUE_FROM_SHORT_ARRAYS` was hand-written as the exact inverse of
  `_SHORT_ARRAYS`; derive it by inversion instead.
- `_SEPARATORS` was a two-entry table read at exactly one call site; inline
  it there.

No behaviour change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…registrable

The engine's docstring claimed it "knows nothing about quantities, units, or
unit systems". That was false, and a downstream package could not in fact
extend it -- registering an axis died with `TypeError: Spec.__new__() got an
unexpected keyword argument`, because `Spec` was a closed NamedTuple. Five
couplings, now cut:

- `Spec` is a frozen Mapping, so a downstream axis is read exactly like a
  built-in one. Build with `Spec.of(**overrides)`, which fills defaults from
  the registry -- writing the mapping out by hand leaves a hole that surfaces
  as a KeyError once a later axis is registered.
- The vocabulary is a registry. `register_axis` / `register_alias` replace the
  hardcoded `_KEYWORDS` / `_LAYOUT_AXES` / `ALIASES` tables. An `Axis` carries
  its keywords, default, and a per-layout translation to renderer kwargs --
  where membership *is* applicability, so the layout-scope check falls out of
  the same table.
- `render` no longer hardcodes unxt's `__pdoc__` kwargs. `quote_units` and
  `show_units` are what the `abbrev` and `unit` axes translate to, declared in
  unxt's layer, and the product renderer forwards every axis it does not own
  to `pparts` -- which is what lets a type act on an axis the engine has never
  heard of.
- jax/numpy leave the engine. `value_str` and the `custom_pdoc_*` hooks were
  only ever called by consumers.
- `unxt._src.fmt` becomes a package: `engine` (domain-agnostic, imports
  nothing from unxt/jax/numpy/astropy) and `axes` (unxt's axes, aliases and
  array helpers). coordinax and galax add theirs as peers of `axes`.

Three tests hold the seam: one asserts the engine's import list stays clean,
one registers a fake downstream axis and checks it is indistinguishable from a
built-in (order-independence, defaults, aliasing, layout scoping), and one
checks registration rejects a name collision in both directions.

That last one caught a real gap while being written: `register_alias` guarded
against keyword collisions but silently overwrote an existing alias -- the
exact failure this registry exists to prevent.

BREAKING CHANGE: `Spec` is a Mapping, not a NamedTuple -- `spec.markup`
becomes `spec["markup"]`, and construction goes through `Spec.of`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ract

`unxt._fmt` re-exported 23 names, most of them engine internals that nothing
outside the engine calls. Widening a surface later is easy and narrowing it is
not, so export only what a downstream package provably needs and let the rest
be reached at `unxt._src.fmt` until something outside this repo asks:

- join in:            pparts, PPart, PGroup
- extend the grammar: Axis, register_axis, register_alias
- render:             pspec, render, Spec

That is 9. Dropped: ALIASES, AXES, MARKUPS, REQUIRED_MARKUP_KEYS,
VALUE_FROM_SHORT_ARRAYS, bad_spec, custom_pdoc_no_kind, custom_pdoc_noarray,
doc_to_str, parse_spec, parts_to_doc, parts_to_markup, unwrap_math, value_str.
None had a caller outside `unxt._src`; unxt's own modules already reach the
engine by the internal path, so nothing moved but the doctest imports.

The shim's `pparts` example now renders through `pspec` rather than
`parts_to_markup`, which is both the public path and the one a downstream type
would actually take -- and it gained a case showing the unit axis reaching
nested quantities through a type that has never heard of it. The docs' axis
example is likewise executable now, so "a downstream axis is first-class" is
checked rather than asserted.

Also drops a stale `FORMAT_PRESETS` reference left in a docstring by the
grammar rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nstarman
nstarman force-pushed the claude/string-formatting-engine-7776fd branch from 7d26d02 to 84631ab Compare August 20, 2026 19:56
@nstarman
nstarman requested a balanced review from Copilot August 20, 2026 19:56
Ponytail-review pass:

- The two layout names were written three times: as `_LAYOUTS` keys, then
  again as the layout axis's `keywords` and its `layouts`. Derive both from
  `_LAYOUTS`, so the names live in one place.
- `pspec` was missing from `engine.__all__`, so the package `__init__` carried
  a bespoke `from .engine import pspec` and a hand-added `"pspec"` in its own
  `__all__`. Export it properly and both special cases go.
- `Spec.__hash__` restored hashability that nothing uses -- no set, dict key
  or `hash()` call anywhere. `Mapping` makes subclasses unhashable on purpose;
  let it.

A fourth finding was wrong and is not applied: the `gap` and `pm` LaTeX role
overrides looked speculative, but the grep behind that call covered `src/` and
`packages/` and not `tests/`. Both have real consumers there -- they are what
`test_a_markup_may_override_a_new_role` and the nesting tests exercise, i.e.
the very demonstration that a new role costs one `MARKUPS` entry. Removing
them turned that test red, which is the test doing its job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 26 changed files in this pull request and generated no new comments.

nstarman and others added 6 commits August 20, 2026 16:13
…arts`

The engine already documented that it is meant to be lifted out into a package
of its own. Say which package, and spell the module so that extraction is
dropping a single underscore:

    from unxt._pparts import pparts, PPart     # today
    from pparts import pparts, PPart           # after extraction

with no other edit at the call site. The nine names re-exported from
`unxt._pparts` are exactly that package's intended surface.

`pparts` is named for the extension point everything turns on -- a type joins
in by declaring what it is made of -- and keeps the `p`-prefix `wadler_lindig`
already uses (`pformat`, `pdoc`, `__pdoc__`). That is the honest form of the
kinship: this engine *feeds* wadler-lindig rather than reimplementing any part
of its algorithm, so naming it after another prettyprinting author -- the
obvious way to look like a sibling -- would claim something untrue. Checked
free on PyPI, as were `polyrepr`, `reprism` and `morpheme`; `reprise`,
`compositor`, `galley` and `prism` are taken.

Only the top-level shim is renamed. `unxt._src.fmt` keeps its name: renaming
that package too would shadow the `pparts` *function* at every registration
site, where `@fmt.pparts.dispatch def pparts(...)` is the established shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trailing Python format spec was a reserved `Spec` key beside the axes,
not a value of one. Two problems came with that:

- It made an invalid state representable. `value` and `value_spec` described
  one thing -- how the payload renders -- so `Spec(value="type",
  value_spec=".2f")` was constructible and meaningless, and needed a
  hand-written consistency check to reject it.
- It leaked unxt's vocabulary into the domain-agnostic engine. That check read
  `resolved.get("value") != "values"` and `layout != "product"`: the axis
  name, the keyword and the layout name are all defined in `axes.py`, unxt's
  layer. The engine/axes seam test could not catch it, because those are
  string literals rather than imports.

An axis may now declare `free_text=(<layouts>)`, saying its value may be
arbitrary text instead of a keyword in those layouts. unxt's `value` axis
claims it for `product`. The engine's job becomes generic: append the trailing
run to whichever axis claims free text, and error if that axis is already set
or the layout does not accept text. At most one axis may claim it, enforced at
registration -- the scan rule makes the trailing run terminal, so there is only
one to give.

`type-.2f` is now caught by the ordinary "value is set twice" rule, and no
axis name, keyword or layout name is hardcoded in the engine any more.

BREAKING CHANGE: `Spec` no longer has a `value_spec` key. `spec["value"]` is
either a keyword value or the format spec; ask `free_text_of(spec)` which.

Docs restructured to match: `conventions.md` keeps a short statement of the
conventions plus an example, and the full grammar, axis table, extension and
layering docs move to a new `guides/formatting.md`. A reference manual had no
business in a conventions page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lper

Ponytail-review pass on the free-text machinery:

- `_FREE_TEXT_AXIS` was a module-level list standing in for a nullable
  singleton, hand-maintained by `register_axis` and read via `[0]` in four
  places. Derive it from `AXES` instead, so it cannot fall out of step with
  the registry -- the same fix as `VALUE_FROM_SHORT_ARRAYS`.
- `free_text_of` was a public-shaped helper with one caller and no export.
  Inline it into `pspec` until a second caller exists.
- Dropped the "no axis accepts free text" guard: unreachable here, since
  unxt's layer always registers one, and untested. Speculative cover for a
  state the standalone package does not have yet either.

Also corrects the previous commit message, which told downstream to ask
`free_text_of(spec)`. That name was never exported, so the instruction did
not work as written; it is now gone rather than half-available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…olds

A verbosity pass over the engine's comments and docstrings, keeping rationale
a current reader needs and dropping post-mortems of superseded revisions:

- `parse_spec`'s inline comment argued its design against the old `value_spec`
  key -- a comparison no reader of this code can see.
- `value_str` carried eleven lines of comment over two lines of code, most of
  it explaining a bug that no longer exists. The operative facts stay: the
  hook builds a summary, so it belongs only on one path and is omitted rather
  than blanked.
- `unwrap_math` cited the `_repr_latex_` defect by name after already stating
  what goes wrong.
- The module docstring's naming rationale is a decision record; it lives in
  the commit that made it and in the guide.
- `_pparts` restated the whole contract in prose written before
  `guides/formatting.md` existed. It keeps the name list -- that is what a
  downstream reader wants without leaving the module -- and points at the
  guide for the grammar and worked examples.

Kept deliberately: `PGroup`'s two bullets, which are what stop someone
"simplifying" the nesting into invalid LaTeX, and `_render_call`'s note that
`__pdoc__` is where reconstruction lives, without which the indirection looks
pointless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Order-independence requires a flat keyword namespace: a bare token has to
identify its axis without help from position. The cost is that every axis
competes for one pool of words, and unxt has already claimed seventeen of the
most generic -- dim, name, type, full, text, array, values among them.

The failure that mattered was not "a name is taken" but whose error it was.
`coordinax` wanting `dim` for manifold dimensionality -- a different and
equally valid meaning -- was refused at *registration*. Two sibling packages
each wanting one word meant `import coordinax; import galax` raised in user
code, with neither library at fault and no fix available to either.

A keyword may now be written `axis:word`. Bare resolves while exactly one axis
claims it, which is every word today, so nothing changes for ordinary use.
When two claim one, the bare form is ambiguous and the error names both
qualified alternatives. Qualification is per-token: a collision costs one
prefix on one word, not a rewritten spec.

Registration no longer refuses a duplicate keyword. It still refuses a clash
with an *alias*, which is a whole spec and so has no qualified form to fall
back on.

A `:` naming no registered axis is not a qualifier -- it falls through to the
format spec like any other non-keyword, so a `:` fill character still works
(`f"{q::>6}"` -> ':::::3 m', verified).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An axis is offered to every type and quietly ignored by one with no concept of
it. That is deliberate -- it is what lets a composite forward axes it has never
heard of, so `f"{interval:name}"` reaches the nested quantities -- but it means
a spec can be accepted and do nothing. Two live examples:

- `f"{q:name}"` on `km / s`: a composite unit has no long name, so the axis
  falls back to the symbol.
- `f"{q:dims}"` on a Quantity: the alias expands to `call-dim`, which only
  unit systems honour. Identical output to `f"{q:call}"`.

Applicability cannot be declared per type -- a composite genuinely does not
honour `unit`, yet forwarding makes it work, and the answer depends on the
object *tree*, which is not known before rendering. So ask by experiment
instead: render again with the axis reset to its default and see whether the
output moves. That needs no cooperation from the type and sees through
composites for free. Measured against both warts above and the live cases
(`name`/`dim` on a simple unit, `mul`, `latex-mul`, `.2f`): no false positives.

Off by default, because each probe is a second render -- 1.7x on a one-axis
spec, 5x on `latex-mul-name`. `repr`/`str` are unaffected either way; they
reach `render` directly and never pass through `pspec`.

The flag is `WARN_INERT_AXES` on the engine rather than a trait in
`unxt.config`, so it survives extraction: a consuming library sets it for
itself instead of the engine reaching into any one library's configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pylint src/unxt` exited 4 on:

- `units.py`: the unit axis's product kwarg was named `unit`, shadowing the
  `unit()` constructor in that same module (W0621). Renamed to `unit_style`
  rather than suppressed: in this codebase `unit` means a unit *object*, so
  the parameter was misleading independently of the linter. An axis chooses
  the kwarg it emits, so this is a one-line change in `axes.py` plus the two
  `pparts` signatures that receive it.
- `engine.py`: the inert-axis probe catches `Exception` (W0718). That breadth
  is deliberate -- the probe calls user `pparts` and must never break the
  render it is only inspecting -- so it is suppressed with that reason rather
  than narrowed.

Verified with the same invocation CI uses: `pylint src/unxt` is back to
10.00/10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the 💚 Fix CI build Fix CI Build. label Aug 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/unxt/_src/quantity/base.py:99

  • This docstring points to unxt._pparts.VALUE_FROM_SHORT_ARRAYS, but VALUE_FROM_SHORT_ARRAYS is not among the names re-exported by the unxt._pparts shim (its __all__/imports only cover Axis, PGroup, PPart, Spec, pparts, pspec, register_alias, register_axis, render). The symbol actually lives in unxt._src.fmt, which is what the code just below uses (fmt.VALUE_FROM_SHORT_ARRAYS). Referencing it under unxt._pparts would mislead a reader following the cross-reference. Consider pointing to unxt._src.fmt.VALUE_FROM_SHORT_ARRAYS instead.
    ``use_short_name``); `unxt._pparts.VALUE_FROM_SHORT_ARRAYS` is the single

codecov was the last red check. Five guards in `engine.py` were unexercised
and one method elsewhere lost its only caller:

- duplicate axis-name registration, `Spec.of` rejecting an unknown axis, and
  `Spec.__repr__` -- straightforward, now tested.
- The probe's broad `except`: a type may legitimately reject the *default* of
  an axis while accepting the value asked for, and the inert check must skip
  that rather than turn a working format call into an exception.
- `pspec` re-raising a render error when the spec carried no free text: only
  a *value spec* failure is reworded as a bad format spec, since rewording a
  keyword-only failure would blame the spec for the type's own error.

`StaticValue.__format__` was covered on main and is not any more, and that is
this branch's doing rather than a flake: `pspec_fallback` used to reach it via
`format(obj.value, spec)`. A quantity's format spec is now applied per element
through `numpy.array2string`, so that path no longer arrives there. The method
stays -- `format(sv, ".2f")` is still legitimate on its own -- but its
docstring claimed it was "hence" how a StaticQuantity formats, which is no
longer true. Corrected, with a doctest.

The engine, its axes and `_pparts` are at 100%. The two remaining misses in
the package (`base.py`, `register_primitives.py`) are identical on
upstream/main, verified by running coverage on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nstarman

Copy link
Copy Markdown
Contributor Author

@adrn I've updated and reworked the engine. It's more flexible, powerful, and ready for use in coordinax and galax.
See updated PR description.
If you like it I'll merge here, then separate it out as a library called pparts for the central engine and then unxt, coordinax, and galax will each implement a registration into the engine. It's very composable!

nstarman and others added 2 commits August 20, 2026 17:55
`:` implied namespacing -- "this word belongs to the unit axis" -- but that is
not what happens. A keyword *sets an axis to a value*, so `unit=dim` is the
operation spelled out and bare `dim` is its shorthand. It also mirrors the
keyword argument the axis translates to, so the spec and the Python API read
the same way, and someone meeting `unit=dim` can infer the axis model without
being told.

`=` is an *align* character in a format spec, which is why it was passed over
at first. That was too quick: align only ever appears at position 0 or 1, so
`=8`, `=>8.1f`, `=^10` and `=+9.2f` can never be read as an assignment. Each
is checked to fall through to the format spec.

Candidates ruled out on evidence rather than taste: `axis{word}` is an
f-string nested replacement field -- it raises `NameError`, or silently
substitutes a same-named local, so a spec would mean different things in
different scopes. `axis\word` warns `SyntaxWarning: invalid escape sequence`
in any non-raw literal. `|`, `&`, `;` and `!` are shell metacharacters, and
specs are the sort of thing that ends up in a CLI flag.

Nothing can break: no word is claimed twice yet, so qualification is
unreachable and no spec anyone has written can contain one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`f"{q:latex}"` emitted `$[1.5,~2.5] \mathrm{m}$` -- a *literal* space, which
LaTeX discards in math mode. The default rendering therefore set the unit
flush against the value.

A regression from this PR: `bare` became the default separator (right for
`f"{q:.2f}"` staying astropy-shaped), and `bare` had no LaTeX spelling, so it
fell back to the plain-text `" "`.

The deeper cause is that `sep` substituted a *literal string* chosen by the
axis, bypassing the markup table entirely -- so no LaTeX override could ever
apply to it, and adding one had no effect. `sep` now names the **role** that
stands in for `mul`, which routes it through the same per-role lookup every
other fragment uses. Each markup then spells its own join: `" "` for text and
HTML, `\,` -- the thin space conventionally set between a quantity and its
unit -- for LaTeX.

`mul` keeps `\;` in LaTeX. `1.5 \times \mathrm{m}` is not how a quantity is
written, so there the axis chooses between two spacings rather than showing an
operator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📝 Add / update documentation Add or update documentation. ✅ Add / update / pass tests Add, update, or pass tests. 🐛 Fix a bug Fix a bug. 💚 Fix CI build Fix CI Build. 💥 Introduce breaking changes Introduce breaking changes. ✨ Introduce new features Introduce new features. ♻️ Refactor code Refactor code. ⚰️ Remove dead code Remove dead code. 🧩 unxt Issues/PRs affecting the main unxt package 🧩 unxt-api Issues/PRs affecting the unxt-api workspace package 🧩 unxt-hypothesis Issues/PRs affecting the unxt-hypothesis workspace package 🧩 unxts-api Issues/PRs affecting the unxts.api namespace package 🧩 unxts-hypothesis Issues/PRs affecting the unxts.hypothesis namespace package 🧩 unxts-interop-gala Issues/PRs affecting the unxts.interop.gala namespace package 🧩 unxts-interop-matplotlib Issues/PRs affecting the unxts.interop.matplotlib namespace package 🧩 unxts-interop-xarray Issues/PRs affecting the unxts.interop.xarray namespace package 🧩 unxts-linalg Issues/PRs affecting the unxts.linalg namespace package 🧩 unxts-parametric Issues/PRs affecting the unxts.parametric namespace package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add more string formatting functionality

3 participants